Jelajahi React Suspense untuk pengambilan data melampaui code splitting. Pahami Fetch-As-You-Render, penanganan error, dan pola masa depan untuk aplikasi global.
Pemuatan Sumber Daya React Suspense: Menguasai Pola Pengambilan Data Modern
Dalam dunia pengembangan web yang dinamis, pengalaman pengguna (UX) adalah yang utama. Aplikasi diharapkan cepat, responsif, dan menyenangkan, terlepas dari kondisi jaringan atau kemampuan perangkat. Bagi pengembang React, ini sering kali berarti manajemen state yang rumit, indikator pemuatan yang kompleks, dan pertempuran terus-menerus melawan data fetching waterfall. Masuklah React Suspense, sebuah fitur yang kuat, meskipun sering disalahpahami, yang dirancang untuk secara fundamental mengubah cara kita menangani operasi asinkron, terutama pengambilan data.
Awalnya diperkenalkan untuk code splitting dengan React.lazy()
, potensi sejati Suspense terletak pada kemampuannya untuk mengorkestrasi pemuatan sumber daya asinkron *apa pun*, termasuk data dari API. Panduan komprehensif ini akan mendalami React Suspense untuk pemuatan sumber daya, menjelajahi konsep intinya, pola pengambilan data fundamental, dan pertimbangan praktis untuk membangun aplikasi global yang berkinerja dan tangguh.
Evolusi Pengambilan Data di React: Dari Imperatif ke Deklaratif
Selama bertahun-tahun, pengambilan data di komponen React sebagian besar mengandalkan pola umum: menggunakan hook useEffect
untuk memulai panggilan API, mengelola state loading dan error dengan useState
, dan melakukan rendering kondisional berdasarkan state ini. Meskipun fungsional, pendekatan ini sering kali menimbulkan beberapa tantangan:
- Proliferasi State Loading: Hampir setiap komponen yang memerlukan data membutuhkan state
isLoading
,isError
, dandata
-nya sendiri, yang menyebabkan boilerplate berulang. - Waterfalls dan Race Conditions: Komponen bertingkat yang mengambil data sering kali menghasilkan permintaan berurutan (waterfalls), di mana komponen induk akan mengambil data, lalu render, lalu komponen anak akan mengambil datanya, dan seterusnya. Ini meningkatkan waktu muat secara keseluruhan. Race conditions juga bisa terjadi ketika beberapa permintaan dimulai, dan respons tiba tidak berurutan.
- Penanganan Error yang Kompleks: Mendistribusikan pesan error dan logika pemulihan di banyak komponen bisa merepotkan, memerlukan prop drilling atau solusi manajemen state global.
- Pengalaman Pengguna yang Kurang Menyenangkan: Beberapa spinner yang muncul dan menghilang, atau pergeseran konten mendadak (layout shifts), dapat menciptakan pengalaman yang mengganggu bagi pengguna.
- Prop Drilling untuk Data dan State: Meneruskan data yang diambil dan state loading/error terkait melalui beberapa tingkat komponen menjadi sumber kompleksitas yang umum.
Pertimbangkan skenario pengambilan data yang umum tanpa Suspense:
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [userId]);
if (isLoading) {
return <p>Memuat profil pengguna...</p>;
}
if (error) {
return <p style={"color: red;"}>Error: {error.message}</p>;
}
if (!user) {
return <p>Tidak ada data pengguna yang tersedia.</p>;
}
return (
<div>
<h2>Pengguna: {user.name}</h2>
<p>Email: {user.email}</p>
<!-- Detail pengguna lainnya -->
</div>
);
}
function App() {
return (
<div>
<h1>Selamat Datang di Aplikasi</h1>
<UserProfile userId={"123"} />
</div>
);
}
Pola ini ada di mana-mana, tetapi memaksa komponen untuk mengelola state asinkronnya sendiri, sering kali menyebabkan hubungan yang erat antara UI dan logika pengambilan data. Suspense menawarkan alternatif yang lebih deklaratif dan efisien.
Memahami React Suspense di Luar Code Splitting
Sebagian besar pengembang pertama kali bertemu Suspense melalui React.lazy()
untuk code splitting, di mana ia memungkinkan Anda untuk menunda pemuatan kode komponen hingga dibutuhkan. Contohnya:
import React, { Suspense, lazy } from 'react';
const LazyComponent = lazy(() => import('./MyHeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Memuat komponen...</div>}>
<LazyComponent />
</Suspense>
);
}
Dalam skenario ini, jika MyHeavyComponent
belum dimuat, boundary <Suspense>
akan menangkap promise yang dilemparkan oleh lazy()
dan menampilkan fallback
hingga kode komponen siap. Wawasan kunci di sini adalah bahwa Suspense bekerja dengan menangkap promise yang dilemparkan selama rendering.
Mekanisme ini tidak eksklusif untuk pemuatan kode. Fungsi apa pun yang dipanggil selama rendering yang melempar promise (misalnya, karena sumber daya belum tersedia) dapat ditangkap oleh boundary Suspense yang lebih tinggi di pohon komponen. Ketika promise tersebut resolve, React mencoba untuk me-render ulang komponen, dan jika sumber daya sekarang tersedia, fallback disembunyikan, dan konten sebenarnya ditampilkan.
Konsep Inti Suspense untuk Pengambilan Data
Untuk memanfaatkan Suspense untuk pengambilan data, kita perlu memahami beberapa prinsip inti:
1. Melempar Promise
Tidak seperti kode asinkron tradisional yang menggunakan async/await
untuk menyelesaikan promise, Suspense mengandalkan fungsi yang *melempar* promise jika data belum siap. Ketika React mencoba me-render komponen yang memanggil fungsi semacam itu, dan data masih tertunda, promise tersebut dilemparkan. React kemudian 'menjeda' rendering komponen itu dan anak-anaknya, mencari boundary <Suspense>
terdekat.
2. Boundary Suspense
Komponen <Suspense>
bertindak sebagai error boundary untuk promise. Ia menerima prop fallback
, yaitu UI yang akan dirender saat salah satu anaknya (atau turunannya) sedang dalam keadaan suspend (yaitu, melempar promise). Setelah semua promise yang dilemparkan di dalam sub-pohonnya resolve, fallback digantikan oleh konten yang sebenarnya.
Satu boundary Suspense dapat mengelola beberapa operasi asinkron. Misalnya, jika Anda memiliki dua komponen dalam boundary <Suspense>
yang sama, dan masing-masing perlu mengambil data, fallback akan ditampilkan hingga *kedua* pengambilan data selesai. Ini menghindari menampilkan UI parsial dan memberikan pengalaman pemuatan yang lebih terkoordinasi.
3. Manajer Cache/Sumber Daya (Tanggung Jawab Userland)
Secara krusial, Suspense sendiri tidak menangani pengambilan data atau caching. Ia hanyalah mekanisme koordinasi. Untuk membuat Suspense bekerja untuk pengambilan data, Anda memerlukan lapisan yang:
- Memulai pengambilan data.
- Menyimpan hasilnya dalam cache (data yang sudah resolve atau promise yang tertunda).
- Menyediakan metode
read()
sinkron yang mengembalikan data dari cache secara langsung (jika tersedia) atau melempar promise yang tertunda (jika tidak).
'Manajer sumber daya' ini biasanya diimplementasikan menggunakan cache sederhana (misalnya, Map atau objek) untuk menyimpan status setiap sumber daya (tertunda, resolve, atau error). Meskipun Anda dapat membuatnya secara manual untuk tujuan demonstrasi, dalam aplikasi dunia nyata, Anda akan menggunakan library pengambilan data yang kuat yang terintegrasi dengan Suspense.
4. Concurrent Mode (Peningkatan React 18)
Meskipun Suspense dapat digunakan di versi React yang lebih lama, kekuatan penuhnya dilepaskan dengan Concurrent React (diaktifkan secara default di React 18 dengan createRoot
). Concurrent Mode memungkinkan React untuk menginterupsi, menjeda, dan melanjutkan pekerjaan rendering. Ini berarti:
- Pembaruan UI Non-Blocking: Ketika Suspense menunjukkan fallback, React dapat terus me-render bagian lain dari UI yang tidak ditangguhkan, atau bahkan menyiapkan UI baru di latar belakang tanpa memblokir thread utama.
- Transisi: API baru seperti
useTransition
memungkinkan Anda untuk menandai pembaruan tertentu sebagai 'transisi,' yang dapat diinterupsi oleh React dan dibuat kurang mendesak, memberikan perubahan UI yang lebih mulus selama pengambilan data.
Pola Pengambilan Data dengan Suspense
Mari kita jelajahi evolusi pola pengambilan data dengan munculnya Suspense.
Pola 1: Fetch-Then-Render (Tradisional dengan Pembungkus Suspense)
Ini adalah pendekatan klasik di mana data diambil, dan baru setelah itu komponen dirender. Meskipun tidak memanfaatkan mekanisme 'lempar promise' secara langsung untuk data, Anda dapat membungkus komponen yang *pada akhirnya* me-render data dalam boundary Suspense untuk menyediakan fallback. Ini lebih tentang menggunakan Suspense sebagai orkestrator UI pemuatan generik untuk komponen yang akhirnya menjadi siap, bahkan jika pengambilan data internalnya masih berbasis useEffect
tradisional.
import React, { Suspense, useState, useEffect } from 'react';
function UserDetails({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
setIsLoading(false);
};
fetchUserData();
}, [userId]);
if (isLoading) {
return <p>Memuat detail pengguna...</p>;
}
return (
<div>
<h3>Pengguna: {user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Contoh Fetch-Then-Render</h1>
<Suspense fallback={<div>Memuat seluruh halaman...</div>}>
<UserDetails userId={"1"} />
</Suspense>
</div>
);
}
Kelebihan: Sederhana untuk dipahami, kompatibel dengan versi sebelumnya. Dapat digunakan sebagai cara cepat untuk menambahkan state loading global.
Kekurangan: Tidak menghilangkan boilerplate di dalam UserDetails
. Masih rentan terhadap waterfalls jika komponen mengambil data secara berurutan. Tidak benar-benar memanfaatkan mekanisme 'lempar-dan-tangkap' Suspense untuk data itu sendiri.
Pola 2: Render-Then-Fetch (Pengambilan di Dalam Render, bukan untuk Produksi)
Pola ini terutama untuk mengilustrasikan apa yang tidak boleh dilakukan dengan Suspense secara langsung, karena dapat menyebabkan loop tak terbatas atau masalah kinerja jika tidak ditangani dengan cermat. Ini melibatkan upaya mengambil data atau memanggil fungsi yang menangguhkan langsung di dalam fase render komponen, *tanpa* mekanisme caching yang tepat.
// JANGAN GUNAKAN INI DI PRODUKSI TANPA LAPISAN CACHING YANG TEPAT
// Ini murni untuk ilustrasi bagaimana 'throw' langsung mungkin bekerja secara konseptual.
let fetchedData = null;
let dataPromise = null;
function fetchDataSynchronously(url) {
if (fetchedData) {
return fetchedData;
}
if (!dataPromise) {
dataPromise = fetch(url)
.then(res => res.json())
.then(data => { fetchedData = data; dataPromise = null; return data; })
.catch(err => { dataPromise = null; throw err; });
}
throw dataPromise; // Di sinilah Suspense devreye girer
}
function UserDetailsBadExample({ userId }) {
const user = fetchDataSynchronously(`/api/users/${userId}`);
return (
<div>
<h3>Pengguna: {user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Render-Then-Fetch (Ilustratif, TIDAK Disarankan Secara Langsung)</h1>
<Suspense fallback={<div>Memuat pengguna...</div>}>
<UserDetailsBadExample userId={"2"} />
</Suspense>
</div>
);
}
Kelebihan: Menunjukkan bagaimana sebuah komponen dapat secara langsung 'meminta' data dan menangguhkan jika belum siap.
Kekurangan: Sangat bermasalah untuk produksi. Sistem fetchedData
dan dataPromise
global yang manual ini sederhana, tidak menangani beberapa permintaan, invalidasi, atau state error secara kuat. Ini adalah ilustrasi primitif dari konsep 'lempar-promise', bukan pola untuk diadopsi.
Pola 3: Fetch-As-You-Render (Pola Suspense yang Ideal)
Ini adalah pergeseran paradigma yang benar-benar dimungkinkan oleh Suspense untuk pengambilan data. Alih-alih menunggu komponen dirender sebelum mengambil datanya, atau mengambil semua data di muka, Fetch-As-You-Render berarti Anda mulai mengambil data *sesegera mungkin*, sering kali *sebelum* atau *bersamaan dengan* proses rendering. Komponen kemudian 'membaca' data dari cache, dan jika data belum siap, mereka menangguhkan. Ide intinya adalah memisahkan logika pengambilan data dari logika rendering komponen.
Untuk mengimplementasikan Fetch-As-You-Render, Anda memerlukan mekanisme untuk:
- Memulai pengambilan data di luar fungsi render komponen (misalnya, saat rute dimasuki, atau tombol diklik).
- Menyimpan promise atau data yang sudah resolve di dalam cache.
- Menyediakan cara bagi komponen untuk 'membaca' dari cache ini. Jika data belum tersedia, fungsi baca akan melempar promise yang tertunda.
Pola ini mengatasi masalah waterfall. Jika dua komponen berbeda membutuhkan data, permintaan mereka dapat dimulai secara paralel, dan UI hanya akan muncul setelah *keduanya* siap, diorkestrasi oleh satu boundary Suspense.
Implementasi Manual (untuk Pemahaman)
Untuk memahami mekanisme yang mendasarinya, mari kita buat manajer sumber daya manual yang disederhanakan. Dalam aplikasi nyata, Anda akan menggunakan library khusus.
import React, { Suspense } from 'react';
// --- Manajer Cache/Sumber Daya Sederhana --- //
const cache = new Map();
function createResource(promise) {
let status = 'pending';
let result;
let suspender = promise.then(
(r) => {
status = 'success';
result = r;
},
(e) => {
status = 'error';
result = e;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
} else if (status === 'error') {
throw result;
} else if (status === 'success') {
return result;
}
},
};
}
function fetchData(key, fetcher) {
if (!cache.has(key)) {
cache.set(key, createResource(fetcher()));
}
return cache.get(key);
}
// --- Fungsi Pengambilan Data --- //
const fetchUserById = (id) => {
console.log(`Mengambil pengguna ${id}...`);
return new Promise(resolve => setTimeout(() => {
const users = {
'1': { id: '1', name: 'Alice Smith', email: 'alice@example.com' },
'2': { id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
'3': { id: '3', name: 'Charlie Brown', email: 'charlie@example.com' }
};
resolve(users[id]);
}, 1500));
};
const fetchPostsByUserId = (userId) => {
console.log(`Mengambil post untuk pengguna ${userId}...`);
return new Promise(resolve => setTimeout(() => {
const posts = {
'1': [{ id: 'p1', title: 'Postingan Pertamaku' }, { id: 'p2', title: 'Petualangan Perjalanan' }],
'2': [{ id: 'p3', title: 'Wawasan Pemrograman' }],
'3': [{ id: 'p4', title: 'Tren Global' }, { id: 'p5', title: 'Masakan Lokal' }]
};
resolve(posts[userId] || []);
}, 2000));
};
// --- Komponen --- //
function UserProfile({ userId }) {
const userResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
const user = userResource.read(); // Ini akan menangguhkan jika data pengguna belum siap
return (
<div>
<h3>Pengguna: {user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
function UserPosts({ userId }) {
const postsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
const posts = postsResource.read(); // Ini akan menangguhkan jika data post belum siap
return (
<div>
<h4>Post oleh {userId}:</h4>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
{posts.length === 0 && <li>Tidak ada post yang ditemukan.</li>}
</ul>
</div>
);
}
// --- Aplikasi --- //
let initialUserResource = null;
let initialPostsResource = null;
function prefetchDataForUser(userId) {
initialUserResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
initialPostsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
}
// Pra-ambil beberapa data sebelum komponen App dirender
prefetchDataForUser('1');
function App() {
return (
<div>
<h1>Fetch-As-You-Render dengan Suspense</h1>
<p>Ini mendemonstrasikan bagaimana pengambilan data dapat terjadi secara paralel, dikoordinasikan oleh Suspense.</p>
<Suspense fallback={<div>Memuat profil pengguna dan post...</div>}>
<UserProfile userId={"1"} />
<UserPosts userId={"1"} />
</Suspense>
<h2>Bagian Lain</h2>
<Suspense fallback={<div>Memuat pengguna lain...</div>}>
<UserProfile userId={"2"} />
</Suspense>
</div>
);
}
Dalam contoh ini:
- Fungsi
createResource
danfetchData
menyiapkan mekanisme caching dasar. - Ketika
UserProfile
atauUserPosts
memanggilresource.read()
, mereka entah mendapatkan data secara langsung atau promise dilemparkan. - Boundary
<Suspense>
terdekat menangkap promise dan menampilkan fallback-nya. - Secara krusial, kita bisa memanggil
prefetchDataForUser('1')
*sebelum* komponenApp
dirender, memungkinkan pengambilan data dimulai lebih awal lagi.
Library untuk Fetch-As-You-Render
Membangun dan memelihara manajer sumber daya yang kuat secara manual itu rumit. Untungnya, beberapa library pengambilan data yang matang telah mengadopsi atau sedang mengadopsi Suspense, menyediakan solusi yang telah teruji:
- React Query (TanStack Query): Menawarkan lapisan pengambilan data dan caching yang kuat dengan dukungan Suspense. Ia menyediakan hook seperti
useQuery
yang dapat menangguhkan. Sangat baik untuk API REST. - SWR (Stale-While-Revalidate): Library pengambilan data populer dan ringan lainnya yang sepenuhnya mendukung Suspense. Ideal untuk API REST, ia berfokus pada penyediaan data dengan cepat (stale) dan kemudian memvalidasi ulang di latar belakang.
- Apollo Client: Klien GraphQL komprehensif yang memiliki integrasi Suspense yang kuat untuk query dan mutasi GraphQL.
- Relay: Klien GraphQL milik Facebook, dirancang dari awal untuk Suspense dan Concurrent React. Ini memerlukan skema GraphQL dan langkah kompilasi tertentu tetapi menawarkan kinerja dan konsistensi data yang tak tertandingi.
- Urql: Klien GraphQL yang ringan dan sangat dapat disesuaikan dengan dukungan Suspense.
Library-library ini mengabstraksi kompleksitas pembuatan dan pengelolaan sumber daya, menangani caching, validasi ulang, pembaruan optimis, dan penanganan error, membuatnya jauh lebih mudah untuk mengimplementasikan Fetch-As-You-Render.
Pola 4: Prefetching dengan Library yang Mendukung Suspense
Prefetching adalah optimisasi yang kuat di mana Anda secara proaktif mengambil data yang kemungkinan akan dibutuhkan pengguna dalam waktu dekat, bahkan sebelum mereka secara eksplisit memintanya. Ini dapat secara drastis meningkatkan kinerja yang dirasakan.
Dengan library yang mendukung Suspense, prefetching menjadi mulus. Anda dapat memicu pengambilan data pada interaksi pengguna yang tidak langsung mengubah UI, seperti mengarahkan kursor ke tautan atau mouse ke tombol.
import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// Anggap ini adalah panggilan API Anda
const fetchProductById = async (id) => {
console.log(`Mengambil produk ${id}...`);
return new Promise(resolve => setTimeout(() => {
const products = {
'A001': { id: 'A001', name: 'Global Widget X', price: 29.99, description: 'Widget serbaguna untuk penggunaan internasional.' },
'B002': { id: 'B002', name: 'Universal Gadget Y', price: 149.99, description: 'Gawai canggih, disukai di seluruh dunia.' },
};
resolve(products[id]);
}, 1000));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // Aktifkan Suspense untuk semua query secara default
},
},
});
function ProductDetails({ productId }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
return (
<div style={{"border": "1px solid #ccc", "padding": "15px", "margin": "10px 0"}}>
<h3>{product.name}</h3>
<p>Harga: ${product.price.toFixed(2)}</p>
<p>{product.description}</p>
</div>
);
}
function ProductList() {
const handleProductHover = (productId) => {
// Pra-ambil data saat pengguna mengarahkan kursor ke tautan produk
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
console.log(`Melakukan prefetch produk ${productId}`);
};
return (
<div>
<h2>Produk Tersedia:</h2>
<ul>
<li>
<a href="#" onMouseEnter={() => handleProductHover('A001')}
onClick={(e) => { e.preventDefault(); /* Navigasi atau tampilkan detail */ }}
>Global Widget X (A001)</a>
</li>
<li>
<a href="#" onMouseEnter={() => handleProductHover('B002')}
onClick={(e) => { e.preventDefault(); /* Navigasi atau tampilkan detail */ }}
>Universal Gadget Y (B002)</a>
</li>
</ul>
<p>Arahkan kursor ke tautan produk untuk melihat prefetching beraksi. Buka tab network untuk mengamati.</p>
</div>
);
}
function App() {
const [showProductA, setShowProductA] = React.useState(false);
const [showProductB, setShowProductB] = React.useState(false);
return (
<QueryClientProvider client={queryClient}>
<h1>Prefetching dengan React Suspense (React Query)</h1>
<ProductList />
<button onClick={() => setShowProductA(true)}>Tampilkan Global Widget X</button>
<button onClick={() => setShowProductB(true)}>Tampilkan Universal Gadget Y</button>
{showProductA && (
<Suspense fallback={<p>Memuat Global Widget X...</p>}>
<ProductDetails productId="A001" />
</Suspense>
)}
{showProductB && (
<Suspense fallback={<p>Memuat Universal Gadget Y...</p>}>
<ProductDetails productId="B002" />
</Suspense>
)}
</QueryClientProvider>
);
}
Dalam contoh ini, mengarahkan kursor ke tautan produk memicu `queryClient.prefetchQuery`, yang memulai pengambilan data di latar belakang. Jika pengguna kemudian mengklik tombol untuk menampilkan detail produk, dan data sudah ada di cache dari prefetch, komponen akan dirender secara instan tanpa menangguhkan. Jika prefetch masih berlangsung atau tidak dimulai, Suspense akan menampilkan fallback hingga data siap.
Penanganan Error dengan Suspense dan Error Boundaries
Meskipun Suspense menangani state 'loading' dengan menampilkan fallback, ia tidak secara langsung menangani state 'error'. Jika promise yang dilemparkan oleh komponen yang menangguhkan mengalami reject (yaitu, pengambilan data gagal), error ini akan merambat ke atas pohon komponen. Untuk menangani error ini dengan baik dan menampilkan UI yang sesuai, Anda perlu menggunakan Error Boundaries.
Error Boundary adalah komponen React yang mengimplementasikan salah satu metode lifecycle componentDidCatch
atau static getDerivedStateFromError
. Ia menangkap error JavaScript di mana saja di dalam pohon komponen anaknya, termasuk error yang dilemparkan oleh promise yang biasanya akan ditangkap oleh Suspense jika masih tertunda.
import React, { Suspense, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// --- Komponen Error Boundary --- //
class MyErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Perbarui state sehingga render berikutnya akan menampilkan UI fallback.
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Anda juga dapat mencatat error ke layanan pelaporan error
console.error("Menangkap sebuah error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
// Anda dapat me-render UI fallback kustom apa pun
return (
<div style={{"border": "2px solid red", "padding": "20px", "margin": "20px 0", "background": "#ffe0e0"}}>
<h2>Terjadi kesalahan!</h2>
<p>{this.state.error && this.state.error.message}</p>
<p>Silakan coba muat ulang halaman atau hubungi dukungan.</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>Coba Lagi</button>
</div>
);
}
return this.props.children;
}
}
// --- Pengambilan Data (dengan potensi error) --- //
const fetchItemById = async (id) => {
console.log(`Mencoba mengambil item ${id}...`);
return new Promise((resolve, reject) => setTimeout(() => {
if (id === 'error-item') {
reject(new Error('Gagal memuat item: Jaringan tidak terjangkau atau item tidak ditemukan.'));
} else if (id === 'slow-item') {
resolve({ id: 'slow-item', name: 'Terkirim dengan Lambat', data: 'Item ini butuh waktu lama tetapi tiba!', status: 'success' });
} else {
resolve({ id, name: `Item ${id}`, data: `Data untuk item ${id}` });
}
}, id === 'slow-item' ? 3000 : 800));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
retry: false, // Untuk demonstrasi, nonaktifkan coba lagi agar error langsung muncul
},
},
});
function DisplayItem({ itemId }) {
const { data: item } = useQuery({
queryKey: ['item', itemId],
queryFn: () => fetchItemById(itemId),
});
return (
<div>
<h3>Detail Item:</h3>
<p>ID: {item.id}</p>
<p>Nama: {item.name}</p>
<p>Data: {item.data}</p>
</div>
);
}
function App() {
const [fetchType, setFetchType] = useState('normal-item');
return (
<QueryClientProvider client={queryClient}>
<h1>Suspense dan Error Boundaries</h1>
<div>
<button onClick={() => setFetchType('normal-item')}>Ambil Item Normal</button>
<button onClick={() => setFetchType('slow-item')}>Ambil Item Lambat</button>
<button onClick={() => setFetchType('error-item')}>Ambil Item Error</button>
</div>
<MyErrorBoundary>
<Suspense fallback={<p>Memuat item melalui Suspense...</p>}>
<DisplayItem itemId={fetchType} />
</Suspense>
</MyErrorBoundary>
</QueryClientProvider>
);
}
Dengan membungkus boundary Suspense Anda (atau komponen yang mungkin menangguhkan) dengan Error Boundary, Anda memastikan bahwa kegagalan jaringan atau error server selama pengambilan data ditangkap dan ditangani dengan baik, mencegah seluruh aplikasi dari crash. Ini memberikan pengalaman yang kuat dan ramah pengguna, memungkinkan pengguna untuk memahami masalah dan berpotensi mencoba lagi.
Manajemen State dan Invalidasi Data dengan Suspense
Penting untuk mengklarifikasi bahwa React Suspense terutama menangani state pemuatan awal dari sumber daya asinkron. Ia tidak secara inheren mengelola cache sisi klien, menangani invalidasi data, atau mengorkestrasi mutasi (operasi create, update, delete) dan pembaruan UI berikutnya.
Di sinilah library pengambilan data yang mendukung Suspense (React Query, SWR, Apollo Client, Relay) menjadi sangat diperlukan. Mereka melengkapi Suspense dengan menyediakan:
- Caching yang Kuat: Mereka memelihara cache dalam memori yang canggih dari data yang diambil, menyajikannya secara instan jika tersedia, dan menangani validasi ulang di latar belakang.
- Invalidasi dan Pengambilan Ulang Data: Mereka menawarkan mekanisme untuk menandai data yang di-cache sebagai 'stale' dan mengambilnya kembali (misalnya, setelah mutasi, interaksi pengguna, atau saat jendela fokus).
- Pembaruan Optimis: Untuk mutasi, mereka memungkinkan Anda memperbarui UI secara langsung (secara optimis) berdasarkan hasil yang diharapkan dari panggilan API, dan kemudian mengembalikannya jika panggilan API yang sebenarnya gagal.
- Sinkronisasi State Global: Mereka memastikan bahwa jika data berubah dari satu bagian aplikasi Anda, semua komponen yang menampilkan data tersebut akan diperbarui secara otomatis.
- State Loading dan Error untuk Mutasi: Meskipun
useQuery
mungkin menangguhkan,useMutation
biasanya menyediakan stateisLoading
danisError
untuk proses mutasi itu sendiri, karena mutasi sering kali interaktif dan memerlukan umpan balik segera.
Tanpa library pengambilan data yang kuat, mengimplementasikan fitur-fitur ini di atas manajer sumber daya Suspense manual akan menjadi pekerjaan yang signifikan, yang pada dasarnya mengharuskan Anda membangun kerangka kerja pengambilan data sendiri.
Pertimbangan Praktis dan Praktik Terbaik
Mengadopsi Suspense untuk pengambilan data adalah keputusan arsitektural yang signifikan. Berikut adalah beberapa pertimbangan praktis untuk aplikasi global:
1. Tidak Semua Data Membutuhkan Suspense
Suspense ideal untuk data penting yang secara langsung memengaruhi rendering awal suatu komponen. Untuk data yang tidak kritis, pengambilan di latar belakang, atau data yang dapat dimuat secara malas tanpa dampak visual yang kuat, useEffect
tradisional atau pra-rendering mungkin masih cocok. Penggunaan Suspense yang berlebihan dapat menyebabkan pengalaman pemuatan yang kurang granular, karena satu boundary Suspense menunggu *semua* anaknya untuk resolve.
2. Granularitas Boundary Suspense
Tempatkan boundary <Suspense>
Anda dengan cermat. Satu boundary besar di bagian atas aplikasi Anda mungkin menyembunyikan seluruh halaman di balik spinner, yang bisa membuat frustrasi. Boundary yang lebih kecil dan lebih granular memungkinkan berbagai bagian halaman Anda untuk dimuat secara independen, memberikan pengalaman yang lebih progresif dan responsif. Misalnya, sebuah boundary di sekitar komponen profil pengguna, dan satu lagi di sekitar daftar produk yang direkomendasikan.
<div>
<h1>Halaman Produk</h1>
<Suspense fallback={<p>Memuat detail produk utama...</p>}>
<ProductDetails id="prod123" />
</Suspense>
<hr />
<h2>Produk Terkait</h2>
<Suspense fallback={<p>Memuat produk terkait...</p>}>
<RelatedProducts category="electronics" />
</Suspense>
</div>
Pendekatan ini berarti pengguna dapat melihat detail produk utama bahkan jika produk terkait masih dimuat.
3. Server-Side Rendering (SSR) dan Streaming HTML
API SSR streaming baru dari React 18 (renderToPipeableStream
) sepenuhnya terintegrasi dengan Suspense. Ini memungkinkan server Anda mengirim HTML segera setelah siap, bahkan jika bagian dari halaman (seperti komponen yang bergantung pada data) masih dimuat. Server dapat melakukan streaming placeholder (dari fallback Suspense) dan kemudian melakukan streaming konten sebenarnya ketika data resolve, tanpa memerlukan render ulang penuh di sisi klien. Ini secara signifikan meningkatkan kinerja pemuatan yang dirasakan untuk pengguna global pada kondisi jaringan yang bervariasi.
4. Adopsi Bertahap
Anda tidak perlu menulis ulang seluruh aplikasi Anda untuk menggunakan Suspense. Anda dapat memperkenalkannya secara bertahap, dimulai dengan fitur atau komponen baru yang paling mendapat manfaat dari pola pemuatan deklaratifnya.
5. Perkakas dan Debugging
Meskipun Suspense menyederhanakan logika komponen, debugging bisa berbeda. React DevTools memberikan wawasan tentang boundary Suspense dan state-nya. Biasakan diri Anda dengan bagaimana library pengambilan data pilihan Anda mengekspos state internalnya (misalnya, React Query Devtools).
6. Timeout untuk Fallback Suspense
Untuk waktu pemuatan yang sangat lama, Anda mungkin ingin memperkenalkan timeout ke fallback Suspense Anda, atau beralih ke indikator pemuatan yang lebih detail setelah penundaan tertentu. Hook useDeferredValue
dan useTransition
di React 18 dapat membantu mengelola state pemuatan yang lebih bernuansa ini, memungkinkan Anda untuk menunjukkan versi 'lama' dari UI saat data baru sedang diambil, atau menunda pembaruan yang tidak mendesak.
Masa Depan Pengambilan Data di React: React Server Components dan Selanjutnya
Perjalanan pengambilan data di React tidak berhenti dengan Suspense sisi klien. React Server Components (RSC) mewakili evolusi yang signifikan, menjanjikan untuk mengaburkan batas antara klien dan server, dan lebih lanjut mengoptimalkan pengambilan data.
- React Server Components (RSC): Komponen ini dirender di server, mengambil data mereka secara langsung, dan kemudian hanya mengirim HTML dan JavaScript sisi klien yang diperlukan ke browser. Ini menghilangkan waterfalls sisi klien, mengurangi ukuran bundle, dan meningkatkan kinerja muat awal. RSC bekerja seiring dengan Suspense: komponen server dapat menangguhkan jika data mereka belum siap, dan server dapat melakukan streaming fallback Suspense ke klien, yang kemudian diganti ketika data resolve. Ini adalah pengubah permainan untuk aplikasi dengan persyaratan data yang kompleks, menawarkan pengalaman yang mulus dan berkinerja tinggi, terutama bermanfaat bagi pengguna di berbagai wilayah geografis dengan latensi yang bervariasi.
- Pengambilan Data Terpadu: Visi jangka panjang untuk React melibatkan pendekatan terpadu untuk pengambilan data, di mana kerangka kerja inti atau solusi yang terintegrasi erat memberikan dukungan kelas satu untuk memuat data baik di server maupun klien, semuanya diorkestrasi oleh Suspense.
- Evolusi Library Berkelanjutan: Library pengambilan data akan terus berevolusi, menawarkan fitur yang lebih canggih untuk caching, invalidasi, dan pembaruan real-time, dibangun di atas kemampuan dasar Suspense.
Seiring React terus matang, Suspense akan menjadi bagian yang semakin sentral dari teka-teki untuk membangun aplikasi yang sangat berkinerja, ramah pengguna, dan dapat dipelihara. Ini mendorong pengembang ke arah cara yang lebih deklaratif dan tangguh dalam menangani operasi asinkron, memindahkan kompleksitas dari komponen individual ke lapisan data yang dikelola dengan baik.
Kesimpulan
React Suspense, awalnya sebuah fitur untuk code splitting, telah berkembang menjadi alat transformatif untuk pengambilan data. Dengan merangkul pola Fetch-As-You-Render dan memanfaatkan library yang mendukung Suspense, pengembang dapat secara signifikan meningkatkan pengalaman pengguna aplikasi mereka, menghilangkan loading waterfalls, menyederhanakan logika komponen, dan menyediakan state pemuatan yang mulus dan terkoordinasi. Dikombinasikan dengan Error Boundaries untuk penanganan error yang kuat dan janji masa depan dari React Server Components, Suspense memberdayakan kita untuk membangun aplikasi yang tidak hanya berkinerja dan tangguh tetapi juga secara inheren lebih menyenangkan bagi pengguna di seluruh dunia. Pergeseran ke paradigma pengambilan data yang didorong oleh Suspense memerlukan penyesuaian konseptual, tetapi manfaatnya dalam hal kejelasan kode, kinerja, dan kepuasan pengguna sangat besar dan sepadan dengan investasinya.